Implied Volatility of Index Options

In the article below, we will (i) automatically find the Option (of choice) closest to At The Money (ATM) and (ii) calculate its Implied Volatility. We focus below on Future (Monthly) Options on the Index .STOXX50E (EURO STOXX 50 EUR PRICE INDEX) ('EUREX') and .SPX (S&P 500 INDEX), although you can apply the logic below for another index. To find the ATM instrument, we simply and efficiently use the Search API. Usually, the calculation of the Black-Scholes-Merton model's Implied Volatility involves numerical techniques, since it is not a closed equation (unless restricting assumptions that log returns follow a standard normal distribution with mean is zero, $\mu$ = 0, and standard deviation is zero, $\sigma$ = 1, are made). If we used these techniques in calculating each Implied Volatility value on our computer, it would take several seconds - if not minutes - for each data point computed. I have chosen to use the Instrument Pricing Analytics (IPA) service in the Refinitiv Data Platform API Family instead, as this service allows me to send model specifications (and variables) and receive several (up to 100) computed Implied Volatility values in one go - in a few seconds. Not only does this save a great deal of time, but also many lines of code!

In [1]:
import refinitiv.data as rd  # This is LSEG's Data and Analytics' API wrapper, called the Refinitiv Data Library for Python.
from refinitiv.data.content import historical_pricing  # We will use this Python Class in `rd` to show the Implied Volatility data already available before our work.
from refinitiv.data.content import search  # We will use this Python Class in `rd` to fid the instrument we are after, closest to At The Money.

import numpy as np  # We need `numpy` for mathematical and array manipilations.
import pandas as pd  # We need `pandas` for datafame and array manipilations.
import calendar  # We use `calendar` to identify holidays and maturity dates of intruments of interest.
import pytz  # We use `pytz` to manipulate time values aiding `calendar` library.
import pandas_market_calendars as mcal  # Used to identify holidays. See `https://github.com/rsheftel/pandas_market_calendars/blob/master/examples/usage.ipynb` for info on this market calendar library
from datetime import datetime, timedelta, timezone  # We use these to manipulate time values
from dateutil.relativedelta import relativedelta  # We use `relativedelta` to manipulate time values aiding `calendar` library.

# `plotly` is a library used to render interactive graphs:
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import plotly.express as px  # This is just to see the implied vol graph when that field is available
import matplotlib.pyplot as plt  # We use `matplotlib` to just in case users do not have an environment suited to `plotly`.
from IPython.display import clear_output  # We use `clear_output` for users who wish to loop graph production on a regular basis.

# Let's authenticate ourseves to LSEG's Data and Analytics service, Refinitiv:
try:  # The following libraries are not available in Codebook, thus this try loop
    rd.open_session(config_name="C:\\Example.DataLibrary.Python-main\\Example.DataLibrary.Python-main\\Configuration\\refinitiv-data.config.json")
    rd.open_session("desktop.workspace")
except:
    rd.open_session()
In [4]:
print(f"Here we are using the refinitiv Data Library version {rd.__version__}")
Here we are using the refinitiv Data Library version 1.0.0b24

EUREX Call Options

In this article, we will attempt to calculate the Implied Volatility (IV) for Future Options on 2 indexes (.STOXX50E & .SPX) trading 'ATM', meaning that the contract's strike price is at (or near - within x%) parity with (equal to) its current treading price (TRDPRC_1). We are also only looking for such Options expiring within a set time window; allowing for the option 'forever', i.e.: that expire whenever after date of calculation. To do so, we 1st have to find the option in question. To find live Options, we best use the Search API. To find Expired Options we will use functions created in Haykaz's amazing articles "Finding Expired Options and Backtesting a Short Iron Condor Strategy" & "Functions to find Option RICs traded on different exchanges"

Finding Live Options (using Search API)

Live Options, in this context, are Options that have not expired at time of computation. To be explicit:

  • 'time of calculation' refers here to the time for which the calculation is done, i.e.: if we compute today an IV for an Option as if it was 3 days ago, 'time of calculation' is 3 days ago.
  • 'time of computation' refers here to the time when we are computing the values, i.e.: if we compute today an IV for an Option as if it was 3 days ago, 'time of computation' is today.

As aforementioned, to find live Options, we best use the Search API: Here we look for options on .STOXX50E that mature on the 3rd friday of July 2023, 2023-07-21:

In [3]:
response1 = search.Definition(
    view = search.Views.SEARCH_ALL, # To see what views are available: `help(search.Views)` & `search.metadata.Definition(view = search.Views.SEARCH_ALL).get_data().data.df.to_excel("SEARCH_ALL.xlsx")`
    query=".STOXX50E",
    select="DocumentTitle, RIC, StrikePrice, ExchangeCode, ExpiryDate, UnderlyingAsset, " +
            "UnderlyingAssetName, UnderlyingAssetRIC, ESMAUnderlyingIndexCode, RCSUnderlyingMarket" +
            "UnderlyingQuoteName, UnderlyingQuoteRIC",
    filter="RCSAssetCategoryLeaf eq 'Option' and RIC eq 'STX*' and DocumentTitle ne '*Weekly*'  " +
    "and CallPutOption eq 'Call' and ExchangeCode eq 'EUX' and " +
    "ExpiryDate ge 2022-07-10 and ExpiryDate lt 2023-07-22",  # ge (greater than or equal to), gt (greater than), lt (less than) and le (less than or equal to). These can only be applied to numeric and date properties.
    top=100,
).get_data()
searchDf1 = response1.data.df
In [4]:
searchDf1
Out[4]:
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
0 Eurex Dow Jones EURO STOXX 50 Index Option 400... STXE40000A3.EX 4000 EUX 2023-01-20 [.STOXX50E]
1 Eurex Dow Jones EURO STOXX 50 Index Option 390... STXE39000A3.EX 3900 EUX 2023-01-20 [.STOXX50E]
2 Eurex Dow Jones EURO STOXX 50 Index Option 395... STXE39500A3.EX 3950 EUX 2023-01-20 [.STOXX50E]
3 Eurex Dow Jones EURO STOXX 50 Index Option 400... STXE40000B3.EX 4000 EUX 2023-02-17 [.STOXX50E]
4 Eurex Dow Jones EURO STOXX 50 Index Option 397... STXE39750A3.EX 3975 EUX 2023-01-20 [.STOXX50E]
... ... ... ... ... ... ...
95 Eurex Dow Jones EURO STOXX 50 Index Option 297... STXE29750B3.EX 2975 EUX 2023-02-17 [.STOXX50E]
96 Eurex Dow Jones EURO STOXX 50 Index Option 402... STXE40250C3.EX 4025 EUX 2023-03-17 [.STOXX50E]
97 Eurex Dow Jones EURO STOXX 50 Index Option 382... STXE38250C3.EX 3825 EUX 2023-03-17 [.STOXX50E]
98 Eurex Dow Jones EURO STOXX 50 Index Option 390... STXE39000G3.EX 3900 EUX 2023-07-21 [.STOXX50E]
99 Eurex Dow Jones EURO STOXX 50 Index Option 400... STXE40000G3.EX 4000 EUX 2023-07-21 [.STOXX50E]

100 rows × 6 columns

Let's say the current underlying price is 3331.7EUR, now we can pick the option with strike price closest to that, i.e.: the most 'At The Money'; note that this means that the option can be in or out the money, as long as it is the closest to at the money:

In [5]:
currentUnderlyingPrc =  rd.get_history(
    universe=[searchDf1.UnderlyingQuoteRIC[0][0]],
    fields=["TRDPRC_1"],
    interval="tick").iloc[-1][0]
In [6]:
currentUnderlyingPrc
Out[6]:
4195.55
In [7]:
searchDf1.iloc[(searchDf1['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]]
Out[7]:
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
77 Eurex Dow Jones EURO STOXX 50 Index Option 420... STXE42000B3.EX 4200 EUX 2023-02-17 [.STOXX50E]

In this instance, for this Call Option, 'STXE33500G3.EX', the strike price is 3350, higher than the spot price of our underlying which is 3331.7. The holder of this 'STXE33500G3.EX' option has the right (but not the obligation) to buy the underlying for 3350EUR, which, was the price of the underlying to stay the same till expiry (3331.7EUR on 2023-07-21), means a loss of (3350 - 3331.7 =) 18.3EUR. This option in this instance is 'Out-The-Money'.

N.B.: When using the Filter in Search and playing with dates, it is good to read the API Playground Documentation; it mentions that: "Dates are written in ISO datetime format. The time portion is optional, as is the timezone (assumed to be UTC unless otherwise specified). Valid examples include 2012-03-11T17\:13:55Z, 2012-03-11T17\:13:55, 2012-03-11T12\:00-03:30, 2012-03-11.":

Function for Expiration days

Most of the time, market agents will be interested in the next expiring Option, unless we are too close to it. We would not be interested, for example, in an option expiring in 1 hour, or even tomorrow, because that is so close (in time) that the information reflected in the Option's trades in the market does not represent future expectations of its underlying, but current expectations of it.

To implement such a logic, we need to know what are the expiry dates of the option that we are interested in. We are looking for a Python function narrowing our search to options expiring on the 3rd Friday of any one month. For info on this function, please read articles "Finding Expired Options and Backtesting a Short Iron Condor Strategy" & "Functions to find Option RICs traded on different exchanges"

In [8]:
def Get_exp_dates(year, days=True, mcal_get_calendar='EUREX'):
    '''
    Get_exp_dates Version 2.0:

    This function gets expiration dates for a year for NDX options, which are the 3rd Fridays of each month.

    Changes
    ----------------------------------------------
    Changed from Version 1.0 to 2.0: Jonathan Legrand changed Haykaz Aramyan's original code to allow
        (i) for the function's holiday argument to be changed, and defaulted to 'EUREX' as opposed to 'CBOE_Index_Options' and
        (ii) for the function to output full date objects as opposed to just days of the month if agument days=True.

    Dependencies
    ----------------------------------------------
    Python library 'pandas_market_calendars' version 3.2

    Parameters
    -----------------------------------------------
    Input:
        year(int): year for which expiration days are requested

        mcal_get_calendar(str): String of the calendar for which holidays have to be taken into account. More on this calendar (link to Github chacked 2022-10-11): https://github.com/rsheftel/pandas_market_calendars/blob/177e7922c7df5ad249b0d066b5c9e730a3ee8596/pandas_market_calendars/exchange_calendar_cboe.py
            Default: mcal_get_calendar='EUREX'

        days(bool): If True, only days of the month is outputed, else it's dataeime objects
            Default: days=True

    Output:
        dates(dict): dictionary of expiration days for each month of a specified year in datetime.date format.
    '''

    # get CBOE market holidays
    EUREXCal = mcal.get_calendar(mcal_get_calendar)
    holidays = EUREXCal.holidays().holidays

    # set calendar starting from Saturday
    c = calendar.Calendar(firstweekday=calendar.SATURDAY)

    # get the 3rd Friday of each month
    exp_dates = {}
    for i in range(1, 13):
        monthcal = c.monthdatescalendar(year, i)
        date = monthcal[2][-1]
        # check if found date is an holiday and get the previous date if it is
        if date in holidays:
            date = date + timedelta(-1)
        # append the date to the dictionary
        if year in exp_dates:
            ### Changed from original code from here on by Jonathan Legrand on 2022-10-11
            if days: exp_dates[year].append(date.day)
            else: exp_dates[year].append(date)
        else:
            if days: exp_dates[year] = [date.day]
            else: exp_dates[year] = [date]
    return exp_dates
In [9]:
fullDates = Get_exp_dates(2022, days=False)
dates = Get_exp_dates(2022)
fullDatesStrDict = {i: [fullDates[i][j].strftime('%Y-%m-%d')
                        for j in range(len(fullDates[i]))]
                    for i in list(fullDates.keys())}
fullDatesDayDict = {i: [fullDates[i][j].day
                        for j in range(len(fullDates[i]))]
                    for i in list(fullDates.keys())}
In [10]:
print(fullDates)
{2022: [datetime.date(2022, 1, 21), datetime.date(2022, 2, 18), datetime.date(2022, 3, 18), datetime.date(2022, 4, 14), datetime.date(2022, 5, 20), datetime.date(2022, 6, 17), datetime.date(2022, 7, 15), datetime.date(2022, 8, 19), datetime.date(2022, 9, 16), datetime.date(2022, 10, 21), datetime.date(2022, 11, 18), datetime.date(2022, 12, 16)]}
In [11]:
print(fullDatesStrDict)
{2022: ['2022-01-21', '2022-02-18', '2022-03-18', '2022-04-14', '2022-05-20', '2022-06-17', '2022-07-15', '2022-08-19', '2022-09-16', '2022-10-21', '2022-11-18', '2022-12-16']}
In [12]:
print(dates)
{2022: [21, 18, 18, 14, 20, 17, 15, 19, 16, 21, 18, 16]}
In [13]:
print(fullDatesDayDict)
{2022: [21, 18, 18, 14, 20, 17, 15, 19, 16, 21, 18, 16]}

Function to find the next expiring Option outside the next x day window

Most of the time, market agents will be interested in the next expiring Option, unless we are too close to it. We would not be interested, for example, in an option expiring in 1 hour, or even tomorrow, because that is so close (in time) that the information reflected in the Option's trades in the market does not represent future expectations of its underlying, but current expectations of it.

E.g.: I would like to know what is the next Future (Monthly) Option (i) on the Index '.STOXX50E' (ii) closest to ATM (i.e.: with an underlying spot price closest to the option's strike price) (ii) Expiring in more than x days (i.e.: not too close to calculated time 't'), let's say 15 days:

In [14]:
x = 15
In [15]:
timeOfCalcDatetime = datetime.now()  # For now, we will focuss on the use-case where we are calculating values for today; later we will allow for it historically for any day going back a few business days.
timeOfCalcStr = datetime.now().strftime('%Y-%m-%d')
timeOfCalcStr
Out[15]:
'2023-01-18'
In [16]:
fullDatesAtTimeOfCalc = Get_exp_dates(timeOfCalcDatetime.year, days=False)  # `timeOfCalcDatetime.year` here is 2023
fullDatesAtTimeOfCalcDatetime = [
    datetime(i.year, i.month, i.day)
    for i in fullDatesAtTimeOfCalc[list(fullDatesAtTimeOfCalc.keys())[0]]]
In [17]:
print(fullDatesAtTimeOfCalcDatetime)
[datetime.datetime(2023, 1, 20, 0, 0), datetime.datetime(2023, 2, 17, 0, 0), datetime.datetime(2023, 3, 17, 0, 0), datetime.datetime(2023, 4, 21, 0, 0), datetime.datetime(2023, 5, 19, 0, 0), datetime.datetime(2023, 6, 16, 0, 0), datetime.datetime(2023, 7, 21, 0, 0), datetime.datetime(2023, 8, 18, 0, 0), datetime.datetime(2023, 9, 15, 0, 0), datetime.datetime(2023, 10, 20, 0, 0), datetime.datetime(2023, 11, 17, 0, 0), datetime.datetime(2023, 12, 15, 0, 0)]
In [18]:
expiryDateOfInt = [i for i in fullDatesAtTimeOfCalcDatetime
                   if i > timeOfCalcDatetime + relativedelta(days=x)][0]
expiryDateOfInt
Out[18]:
datetime.datetime(2023, 2, 17, 0, 0)

Now we can look for the one option we're after:

In [19]:
response2 = search.Definition(
    view=search.Views.SEARCH_ALL, # To see what views are available: `help(search.Views)` & `search.metadata.Definition(view = search.Views.SEARCH_ALL).get_data().data.df.to_excel("SEARCH_ALL.xlsx")`
    query=".STOXX50E",
    select="DocumentTitle, RIC, StrikePrice, ExchangeCode, ExpiryDate, UnderlyingAsset, " +
            "UnderlyingAssetName, UnderlyingAssetRIC, ESMAUnderlyingIndexCode, RCSUnderlyingMarket" +
            "UnderlyingQuoteName, UnderlyingQuoteRIC",
    filter="RCSAssetCategoryLeaf eq 'Option' and RIC eq 'STX*' and DocumentTitle ne '*Weekly*' " +
    "and CallPutOption eq 'Call' and ExchangeCode eq 'EUX' and " +
    f"ExpiryDate ge {(expiryDateOfInt - relativedelta(days=1)).strftime('%Y-%m-%d')} " +
    f"and ExpiryDate lt {(expiryDateOfInt + relativedelta(days=1)).strftime('%Y-%m-%d')}",  # ge (greater than or equal to), gt (greater than), lt (less than) and le (less than or equal to). These can only be applied to numeric and date properties.
    top=10000,
).get_data()
searchDf2 = response2.data.df
In [20]:
searchDf2
Out[20]:
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
0 Eurex Dow Jones EURO STOXX 50 Index Option 400... STXE40000B3.EX 4000 EUX 2023-02-17 [.STOXX50E]
1 Eurex Dow Jones EURO STOXX 50 Index Option 390... STXE39000B3.EX 3900 EUX 2023-02-17 [.STOXX50E]
2 Eurex Dow Jones EURO STOXX 50 Index Option 380... STXE38000B3.EX 3800 EUX 2023-02-17 [.STOXX50E]
3 Eurex Dow Jones EURO STOXX 50 Index Option 395... STXE39500B3.EX 3950 EUX 2023-02-17 [.STOXX50E]
4 Eurex Dow Jones EURO STOXX 50 Index Option 385... STXE38500B3.EX 3850 EUX 2023-02-17 [.STOXX50E]
... ... ... ... ... ... ...
145 Eurex Dow Jones EURO STOXX 50 Index Option 502... STXE50250B3.EX 5025 EUX 2023-02-17 [.STOXX50E]
146 Eurex Dow Jones EURO STOXX 50 Index Option 507... STXE50750B3.EX 5075 EUX 2023-02-17 [.STOXX50E]
147 Eurex Dow Jones EURO STOXX 50 Index Option 505... STXE50500B3.EX 5050 EUX 2023-02-17 [.STOXX50E]
148 Eurex Dow Jones EURO STOXX 50 Index Option 512... STXE51250B3.EX 5125 EUX 2023-02-17 [.STOXX50E]
149 Eurex Dow Jones EURO STOXX 50 Index Option 517... STXE51750B3.EX 5175 EUX 2023-02-17 [.STOXX50E]

150 rows × 6 columns

And again, we can collect the closest to ATM:

In [21]:
searchDf2.iloc[(searchDf2['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]]
Out[21]:
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
19 Eurex Dow Jones EURO STOXX 50 Index Option 420... STXE42000B3.EX 4200 EUX 2023-02-17 [.STOXX50E]

Now we have our instrument:

In [22]:
instrument = searchDf2.iloc[(searchDf2['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]].RIC.values[0]
instrument
Out[22]:
'STXE42000B3.EX'

Refinitiv-provided Daily Implied Volatility

Refinitiv provides pre-calculated Implied Volatility values, but they are daily, and we will look into calculating them in higher frequencies:

In [23]:
## Example Options:

# instrument_1 = 'SPXv212240000.U'
# instrument_2 = 'STXE35500J2.EX'  # Eurex Dow Jones EURO STOXX 50 Index Option 3550 Call Oct 2022, Stock Index Cash Option, Underlying RIC: .STOXX50E
# instrument_3 = 'SPXj212240000.U'
In [24]:
datetime.now().isoformat(timespec='minutes')
Out[24]:
'2023-01-18T15:12'
In [25]:
start = (timeOfCalcDatetime - pd.tseries.offsets.BDay(5)).strftime('%Y-%m-%dT%H:%M:%S.%f')  # '2022-10-05T07:30:00.000'
endDateTime = datetime.now()
end = endDateTime.strftime('%Y-%m-%dT%H:%M:%S.%f')  #  e.g.: '2022-09-09T20:00:00.000'
end
Out[25]:
'2023-01-18T15:12:02.823222'
In [26]:
_RefDailyImpVolDf = historical_pricing.events.Definition(
    instrument, fields=['IMP_VOLT'], count=2000).get_data()
In [27]:
_RefDailyImpVolDf.data.df.head()
Out[27]:
STXE42000B3.EX IMP_VOLT
Timestamp
2022-10-18 23:54:57.490 20.3118
2022-10-19 23:55:09.784 20.0319
2022-10-20 23:55:10.390 19.7213
2022-10-21 23:55:10.511 19.9746
2022-10-24 23:55:12.664 20.2399
In [28]:
try: RefDailyImpVolDf = _RefDailyImpVolDf.data.df.drop(['EVENT_TYPE'], axis=1) # In codebook, this line is needed
except: RefDailyImpVolDf = _RefDailyImpVolDf.data.df # If outside of codebook
fig = px.line(RefDailyImpVolDf, title = RefDailyImpVolDf.columns.name + " " + RefDailyImpVolDf.columns[0]) # This is just to see the implied vol graph when that field is available
fig.show()

Option Price

In [29]:
# rd.get_history(
#     universe=["STXE35500J2.EX"],
#     fields=["TRDPRC_1"],
#     interval="tick")
In [30]:
_optnMrktPrice = rd.get_history(
    universe=[instrument],
    fields=["TRDPRC_1"],
    interval="10min",
    start=start,  # Ought to always start at 4 am for OPRA exchanged Options, more info in the article below
    end=end)  # Ought to always end at 8 pm for OPRA exchanged Options, more info in the article below

As you can see, there isn't nessesarily a trade every 10 min.:

In [31]:
_optnMrktPrice.head()
Out[31]:
STXE42000B3.EX TRDPRC_1
Timestamp
2023-01-11 15:30:00 47.0
2023-01-11 15:40:00 45.5
2023-01-11 15:50:00 41.8
2023-01-11 16:20:00 42.9
2023-01-12 08:00:00 49.0

However, for the statistical inferences that we will make further in the article, when we will calculate Implied Volatilities and therefore implement the Black Scholes model, we will need 'continuous timeseries' with which to deal. There are several ways to go from discrete time series (like ours, even if we go down to tick data), but for this article, we will 1st focus on making 'buckets' of 10 min. If no trade is made in any 10 min. bucket, we will assume the price to have stayed the same as previously, throughout the exchange's trading hours which are:

thankfully this is simple. Let's stick with the EUREX for now:

In [32]:
optnMrktPrice = _optnMrktPrice.resample('10Min').mean() # get a datapoint every 10 min
optnMrktPrice = optnMrktPrice[optnMrktPrice.index.strftime('%Y-%m-%d').isin([i for i in _optnMrktPrice.index.strftime('%Y-%m-%d').unique()])]  # Only keep trading days
optnMrktPrice = optnMrktPrice.loc[(optnMrktPrice.index.strftime('%H:%M:%S') >= '07:30:00') & (optnMrktPrice.index.strftime('%H:%M:%S') <= '22:00:00')]  # Only keep trading hours
optnMrktPrice.fillna(method='ffill', inplace=True)  # Forward Fill to populate NaN values
print(f"Our dataframe started at {str(optnMrktPrice.index[0])} and went on continuously till {str(optnMrktPrice.index[-1])}, so out of trading hours rows are removed")
optnMrktPrice
Our dataframe started at 2023-01-11 15:30:00 and went on continuously till 2023-01-18 14:00:00, so out of trading hours rows are removed
Out[32]:
STXE42000B3.EX TRDPRC_1
Timestamp
2023-01-11 15:30:00 47.0
2023-01-11 15:40:00 45.5
2023-01-11 15:50:00 41.8
2023-01-11 16:00:00 41.8
2023-01-11 16:10:00 41.8
... ...
2023-01-18 13:20:00 66.1
2023-01-18 13:30:00 67.7
2023-01-18 13:40:00 67.7
2023-01-18 13:50:00 67.7
2023-01-18 14:00:00 67.6

432 rows × 1 columns

Note that the option might not have traded in the past 10 min. This can cause issues in the code below, we thus ought to add a row for the current time:

In [33]:
# optnMrktPrice = optnMrktPrice.append(
#     pd.DataFrame(
#         [[pd.NA]], columns=optnMrktPrice.columns,
#         index=[(endDateTime + (datetime.min - endDateTime) % timedelta(minutes=10))]))
# optnMrktPrice

Note also that one may want to only look at 'At Option Trade' datapoints, i.e.: Implied Volatility when a trade is made for the Option, but not when none is made. For this, we will use the 'At Trade' (AT) dataframes:

In [34]:
AToptnMrktPrice = _optnMrktPrice
AToptnMrktPrice
Out[34]:
STXE42000B3.EX TRDPRC_1
Timestamp
2023-01-11 15:30:00 47.0
2023-01-11 15:40:00 45.5
2023-01-11 15:50:00 41.8
2023-01-11 16:20:00 42.9
2023-01-12 08:00:00 49.0
... ...
2023-01-18 12:10:00 67.0
2023-01-18 12:40:00 68.2
2023-01-18 13:00:00 66.1
2023-01-18 13:30:00 67.7
2023-01-18 14:00:00 67.6

76 rows × 1 columns

Underlying Asset Price

Now let's get data for the underying, which we need to calculate IV:

In [35]:
underlying = searchDf2.iloc[(searchDf2['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]].UnderlyingQuoteRIC.values[0][0]
underlying
Out[35]:
'.STOXX50E'

If you are interested in the opening times of any one exchange, you can use the following:

In [36]:
hoursDf = rd.get_data(universe=["EUREX21"],
                      fields=["ROW80_10"])
display(hoursDf)
hoursDf.iloc[0,1]
Instrument ROW80_10
0 EUREX21 OGBL/OGBM/OGBS 07:30-08:00 08:0...
Out[36]:
'       OGBL/OGBM/OGBS     07:30-08:00     08:00-19:00     19:00-20:00           '
In [37]:
_underlyingMrktPrice = rd.get_history(
    universe=[underlying],
    fields=["TRDPRC_1"],
    interval="10min",
    start=start,
    end=end)
In [38]:
_underlyingMrktPrice
Out[38]:
.STOXX50E TRDPRC_1
Timestamp
2023-01-11 15:20:00 4106.15
2023-01-11 15:30:00 4101.02
2023-01-11 15:40:00 4101.08
2023-01-11 15:50:00 4093.84
2023-01-11 16:00:00 4092.42
... ...
2023-01-18 13:30:00 4185.80
2023-01-18 13:40:00 4189.71
2023-01-18 13:50:00 4191.65
2023-01-18 14:00:00 4197.47
2023-01-18 14:10:00 4196.28

259 rows × 1 columns

In [39]:
ATunderlyingMrktPrice = AToptnMrktPrice.join(
    _underlyingMrktPrice, lsuffix='_OptPr', rsuffix='_UnderlyingPr', how='inner')
ATunderlyingMrktPrice
Out[39]:
TRDPRC_1_OptPr TRDPRC_1_UnderlyingPr
Timestamp
2023-01-11 15:30:00 47.0 4101.02
2023-01-11 15:40:00 45.5 4101.08
2023-01-11 15:50:00 41.8 4093.84
2023-01-11 16:20:00 42.9 4097.62
2023-01-12 08:00:00 49.0 4109.39
... ... ...
2023-01-18 12:10:00 67.0 4185.89
2023-01-18 12:40:00 68.2 4188.09
2023-01-18 13:00:00 66.1 4185.16
2023-01-18 13:30:00 67.7 4185.80
2023-01-18 14:00:00 67.6 4197.47

76 rows × 2 columns

Let's put it all in one data-frame, df. Some datasets will have data going from the time we sort for start all the way to end. Some won't because no trade happened in the past few minutes/hours. We ought to base ourselves on the dataset with values getting closer to end and ffill for the other column. As a result, the following if loop is needed:

In [40]:
if optnMrktPrice.index[-1] >= _underlyingMrktPrice.index[-1]:
    df = optnMrktPrice.copy()
    df['underlying ' + underlying + ' TRDPRC_1'] = _underlyingMrktPrice
else:
    df = _underlyingMrktPrice.copy()
    df.rename(columns={"TRDPRC_1": 'underlying ' + underlying + ' TRDPRC_1'}, inplace=True)
    df['TRDPRC_1'] = optnMrktPrice
    df.columns.name = optnMrktPrice.columns.name
df.fillna(method='ffill', inplace=True)  # Forward Fill to populate NaN values
df = df.dropna()
df
Out[40]:
STXE42000B3.EX underlying .STOXX50E TRDPRC_1 TRDPRC_1
Timestamp
2023-01-11 15:30:00 4101.02 47.0
2023-01-11 15:40:00 4101.08 45.5
2023-01-11 15:50:00 4093.84 41.8
2023-01-11 16:00:00 4092.42 41.8
2023-01-11 16:10:00 4096.31 41.8
... ... ...
2023-01-18 13:30:00 4185.80 67.7
2023-01-18 13:40:00 4189.71 67.7
2023-01-18 13:50:00 4191.65 67.7
2023-01-18 14:00:00 4197.47 67.6
2023-01-18 14:10:00 4196.28 67.6

258 rows × 2 columns

In [41]:
ATdf = ATunderlyingMrktPrice

Strike Price

In [42]:
strikePrice = searchDf2.iloc[(searchDf2['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]].StrikePrice.values[0]
In [43]:
strikePrice
Out[43]:
4200

Risk-Free Interest Rate

In [44]:
_EurRfRate = rd.get_history(
    universe=['EURIBOR3MD='],  # USD3MFSR=, USDSOFR=
    fields=['TR.FIXINGVALUE'],
    # Since we will use `dropna()` as a way to select the rows we are after later on in the code, we need to ask for more risk-free data than needed, just in case we don't have enough:
    start=(datetime.strptime(start, '%Y-%m-%dT%H:%M:%S.%f') - timedelta(days=1)).strftime('%Y-%m-%d'),
    end=(datetime.strptime(end, '%Y-%m-%dT%H:%M:%S.%f') + timedelta(days=1)).strftime('%Y-%m-%d'))
In [45]:
_EurRfRate
Out[45]:
EURIBOR3MD= Fixing Value
Date
2023-01-10 2.284
2023-01-11 2.298
2023-01-12 2.288
2023-01-13 2.328
2023-01-16 2.334
2023-01-17 2.335
2023-01-18 2.342
2023-01-18 2.342

Euribor values are released daily at 11am CET, and it is published as such on Refinitiv:

In [46]:
EurRfRate = _EurRfRate.resample('10Min').mean().fillna(method='ffill')
df['EurRfRate'] = EurRfRate
df = df.fillna(method='ffill')
df
Out[46]:
STXE42000B3.EX underlying .STOXX50E TRDPRC_1 TRDPRC_1 EurRfRate
Timestamp
2023-01-11 15:30:00 4101.02 47.0 2.298
2023-01-11 15:40:00 4101.08 45.5 2.298
2023-01-11 15:50:00 4093.84 41.8 2.298
2023-01-11 16:00:00 4092.42 41.8 2.298
2023-01-11 16:10:00 4096.31 41.8 2.298
... ... ... ...
2023-01-18 13:30:00 4185.80 67.7 2.335
2023-01-18 13:40:00 4189.71 67.7 2.335
2023-01-18 13:50:00 4191.65 67.7 2.335
2023-01-18 14:00:00 4197.47 67.6 2.335
2023-01-18 14:10:00 4196.28 67.6 2.335

258 rows × 3 columns

Now for the At Trade dataframe:

In [47]:
pd.options.mode.chained_assignment = None  # default='warn'
ATunderlyingMrktPrice['EurRfRate'] = [pd.NA for i in ATunderlyingMrktPrice.index]
for i in _EurRfRate.index:
    _i = str(i)[:10]
    for n, j in enumerate(ATunderlyingMrktPrice.index):
        if _i in str(j):
            if len(_EurRfRate.loc[i].values) == 2:
                ATunderlyingMrktPrice['EurRfRate'].iloc[n] = _EurRfRate.loc[i].values[0][0]
            elif len(_EurRfRate.loc[i].values) == 1:
                ATunderlyingMrktPrice['EurRfRate'].iloc[n] = _EurRfRate.loc[i].values[0]
In [48]:
ATdf = ATunderlyingMrktPrice.copy()
ATdf
Out[48]:
TRDPRC_1_OptPr TRDPRC_1_UnderlyingPr EurRfRate
Timestamp
2023-01-11 15:30:00 47.0 4101.02 2.298
2023-01-11 15:40:00 45.5 4101.08 2.298
2023-01-11 15:50:00 41.8 4093.84 2.298
2023-01-11 16:20:00 42.9 4097.62 2.298
2023-01-12 08:00:00 49.0 4109.39 2.288
... ... ... ...
2023-01-18 12:10:00 67.0 4185.89 2.342
2023-01-18 12:40:00 68.2 4188.09 2.342
2023-01-18 13:00:00 66.1 4185.16 2.342
2023-01-18 13:30:00 67.7 4185.80 2.342
2023-01-18 14:00:00 67.6 4197.47 2.342

76 rows × 3 columns

Annualized Continuous Dividend Rate

We are going to assume no dividends.

Calculating IV

On the Developer Portal, one can see documentation about the Instrument Pricing Analytics service that allows access to calculating functions (that use to be called 'AdFin'). This service is accessible via several RESTful endpoints (in a family of endpoints called 'Quantitative Analytics') which can be used via RD:

Data returned this far was time-stamped in the GMT Time Zone, we need to re-calibrate it to the timezone of our machine:

In [49]:
dfGMT = df.copy()
dfLocalTimeZone = df.copy()
dfLocalTimeZone.index = [
    df.index[i].replace(
        tzinfo=pytz.timezone(
            'GMT')).astimezone(
        tz=datetime.now().astimezone().tzinfo)
    for i in range(len(df))]
In [50]:
dfGMT
Out[50]:
STXE42000B3.EX underlying .STOXX50E TRDPRC_1 TRDPRC_1 EurRfRate
Timestamp
2023-01-11 15:30:00 4101.02 47.0 2.298
2023-01-11 15:40:00 4101.08 45.5 2.298
2023-01-11 15:50:00 4093.84 41.8 2.298
2023-01-11 16:00:00 4092.42 41.8 2.298
2023-01-11 16:10:00 4096.31 41.8 2.298
... ... ... ...
2023-01-18 13:30:00 4185.80 67.7 2.335
2023-01-18 13:40:00 4189.71 67.7 2.335
2023-01-18 13:50:00 4191.65 67.7 2.335
2023-01-18 14:00:00 4197.47 67.6 2.335
2023-01-18 14:10:00 4196.28 67.6 2.335

258 rows × 3 columns

In [51]:
dfLocalTimeZone
Out[51]:
STXE42000B3.EX underlying .STOXX50E TRDPRC_1 TRDPRC_1 EurRfRate
2023-01-11 16:30:00+01:00 4101.02 47.0 2.298
2023-01-11 16:40:00+01:00 4101.08 45.5 2.298
2023-01-11 16:50:00+01:00 4093.84 41.8 2.298
2023-01-11 17:00:00+01:00 4092.42 41.8 2.298
2023-01-11 17:10:00+01:00 4096.31 41.8 2.298
... ... ... ...
2023-01-18 14:30:00+01:00 4185.80 67.7 2.335
2023-01-18 14:40:00+01:00 4189.71 67.7 2.335
2023-01-18 14:50:00+01:00 4191.65 67.7 2.335
2023-01-18 15:00:00+01:00 4197.47 67.6 2.335
2023-01-18 15:10:00+01:00 4196.28 67.6 2.335

258 rows × 3 columns

Now for the At Trade dataframe:

In [52]:
ATdfGMT = ATdf.copy()
ATdfLocalTimeZone = ATdf.copy()
ATdfLocalTimeZone.index = [
    ATdf.index[i].replace(
        tzinfo=pytz.timezone(
            'GMT')).astimezone(
        tz=datetime.now().astimezone().tzinfo)
    for i in range(len(ATdf))]
ATdfGMT
Out[52]:
TRDPRC_1_OptPr TRDPRC_1_UnderlyingPr EurRfRate
Timestamp
2023-01-11 15:30:00 47.0 4101.02 2.298
2023-01-11 15:40:00 45.5 4101.08 2.298
2023-01-11 15:50:00 41.8 4093.84 2.298
2023-01-11 16:20:00 42.9 4097.62 2.298
2023-01-12 08:00:00 49.0 4109.39 2.288
... ... ... ...
2023-01-18 12:10:00 67.0 4185.89 2.342
2023-01-18 12:40:00 68.2 4188.09 2.342
2023-01-18 13:00:00 66.1 4185.16 2.342
2023-01-18 13:30:00 67.7 4185.80 2.342
2023-01-18 14:00:00 67.6 4197.47 2.342

76 rows × 3 columns

In [53]:
ATdfLocalTimeZone
Out[53]:
TRDPRC_1_OptPr TRDPRC_1_UnderlyingPr EurRfRate
2023-01-11 16:30:00+01:00 47.0 4101.02 2.298
2023-01-11 16:40:00+01:00 45.5 4101.08 2.298
2023-01-11 16:50:00+01:00 41.8 4093.84 2.298
2023-01-11 17:20:00+01:00 42.9 4097.62 2.298
2023-01-12 09:00:00+01:00 49.0 4109.39 2.288
... ... ... ...
2023-01-18 13:10:00+01:00 67.0 4185.89 2.342
2023-01-18 13:40:00+01:00 68.2 4188.09 2.342
2023-01-18 14:00:00+01:00 66.1 4185.16 2.342
2023-01-18 14:30:00+01:00 67.7 4185.80 2.342
2023-01-18 15:00:00+01:00 67.6 4197.47 2.342

76 rows × 3 columns

In [54]:
universeL = [
        {
          "instrumentType": "Option",
          "instrumentDefinition": {
            "buySell": "Buy",
            "underlyingType": "Eti",
            "instrumentCode": instrument,
            "strike": str(strikePrice),
          },
          "pricingParameters": {
            "marketValueInDealCcy": str(dfLocalTimeZone['TRDPRC_1'][i]),
            "riskFreeRatePercent": str(dfLocalTimeZone['EurRfRate'][i]),
            "underlyingPrice": str(dfLocalTimeZone['underlying ' + underlying + ' TRDPRC_1'][i]),
            "pricingModelType": "BlackScholes",
            "dividendType": "ImpliedYield",
            "volatilityType": "Implied",
            "underlyingTimeStamp": "Default",
            "reportCcy": "EUR"
          }
        }
      for i in range(len(dfLocalTimeZone.index))]
In [55]:
ATuniverseL = [
        {
          "instrumentType": "Option",
          "instrumentDefinition": {
            "buySell": "Buy",
            "underlyingType": "Eti",
            "instrumentCode": instrument,
            "strike": str(strikePrice),
          },
          "pricingParameters": {
            "marketValueInDealCcy": str(ATdfLocalTimeZone['TRDPRC_1_OptPr'][i]),
            "riskFreeRatePercent": str(ATdfLocalTimeZone['EurRfRate'][i]),
            "underlyingPrice": str(ATdfLocalTimeZone['TRDPRC_1_UnderlyingPr'][i]),
            "pricingModelType": "BlackScholes",
            "dividendType": "ImpliedYield",
            "volatilityType": "Implied",
            "underlyingTimeStamp": "Default",
            "reportCcy": "EUR"
          }
        }
      for i in range(len(ATdfLocalTimeZone.index))]
In [56]:
def Chunks(lst, n):
    """Yield successive n-sized chunks from lst."""
    for i in range(0, len(lst), n):
        yield lst[i:i + n]
In [57]:
requestFields = [
    "MarketValueInDealCcy", "RiskFreeRatePercent",
    "UnderlyingPrice", "PricingModelType",
    "DividendType", "VolatilityType",
    "UnderlyingTimeStamp", "ReportCcy",
    "VolatilityType", "Volatility",
    "DeltaPercent", "GammaPercent",
    "RhoPercent", "ThetaPercent",
    "VegaPercent"]
In [58]:
for i, j in enumerate(Chunks(universeL, 100)):
    print(f"Batch of (100 or fewer) requests no.: {str(i+1)}/{str(len([i for i in Chunks(universeL, 100)]))}")
    # Example request with Body Parameter - Symbology Lookup
    request_definition = rd.delivery.endpoint_request.Definition(
        method=rd.delivery.endpoint_request.RequestMethod.POST,
        url='https://api.refinitiv.com/data/quantitative-analytics/v1/financial-contracts',
        body_parameters={"fields": requestFields,
                         "outputs": ["Data", "Headers"],
                         "universe": j})

    response3 = request_definition.get_data()
    headers_name = [h['name'] for h in response3.data.raw['headers']]

    if i == 0:
        response3df = pd.DataFrame(data=response3.data.raw['data'], columns=headers_name)
    else:
        _response3df = pd.DataFrame(data=response3.data.raw['data'], columns=headers_name)
        response3df = response3df.append(_response3df, ignore_index=True)
Batch of (100 or fewer) requests no.: 1/3
Batch of (100 or fewer) requests no.: 2/3
Batch of (100 or fewer) requests no.: 3/3
In [59]:
response3df
Out[59]:
MarketValueInDealCcy RiskFreeRatePercent UnderlyingPrice PricingModelType DividendType VolatilityType UnderlyingTimeStamp ReportCcy VolatilityType Volatility DeltaPercent GammaPercent RhoPercent ThetaPercent VegaPercent
0 47.0 2.298 4101.02 BlackScholes ImpliedYield Calculated Default EUR Calculated 18.338938 0.337790 0.001694 1.099958 -1.333829 4.293654
1 45.5 2.298 4101.08 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.984226 0.334519 0.001721 1.090182 -1.303460 4.277498
2 41.8 2.298 4093.84 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.668984 0.318880 0.001721 1.038611 -1.253614 4.187742
3 41.8 2.298 4092.42 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.777002 0.317581 0.001708 1.033869 -1.258492 4.179102
4 41.8 2.298 4096.31 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.480568 0.321178 0.001743 1.046995 -1.245046 4.202855
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
253 67.7 2.335 4185.80 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.346159 0.482487 0.002161 1.604296 -1.255045 4.776754
254 67.7 2.335 4189.71 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.948373 0.490254 0.002218 1.632593 -1.225884 4.784252
255 67.7 2.335 4191.65 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.748820 0.494263 0.002248 1.647186 -1.211058 4.787322
256 67.6 2.335 4197.47 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.119869 0.506958 0.002344 1.693431 -1.163375 4.793479
257 67.6 2.335 4196.28 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.245401 0.504268 0.002325 1.683656 -1.172981 4.792630

258 rows × 15 columns

In [60]:
for i, j in enumerate(Chunks(ATuniverseL, 100)):
    print(f"Batch of (100 or fewer) requests no.: {str(i+1)}/{str(len([i for i in Chunks(ATuniverseL, 100)]))}")
    # Example request with Body Parameter - Symbology Lookup
    ATrequest_definition = rd.delivery.endpoint_request.Definition(
        method=rd.delivery.endpoint_request.RequestMethod.POST,
        url='https://api.refinitiv.com/data/quantitative-analytics/v1/financial-contracts',
        body_parameters={"fields": requestFields,
                         "outputs": ["Data", "Headers"],
                         "universe": j})

    ATresponse3 = ATrequest_definition.get_data()
    ATheaders_name = [h['name'] for h in ATresponse3.data.raw['headers']]

    if i == 0:
        ATresponse3df = pd.DataFrame(data=ATresponse3.data.raw['data'], columns=ATheaders_name)
    else:
        _ATresponse3df = pd.DataFrame(data=ATresponse3.data.raw['data'], columns=ATheaders_name)
        ATresponse3df = ATresponse3df.append(_ATresponse3df, ignore_index=True)
Batch of (100 or fewer) requests no.: 1/1
In [61]:
ATresponse3df
Out[61]:
MarketValueInDealCcy RiskFreeRatePercent UnderlyingPrice PricingModelType DividendType VolatilityType UnderlyingTimeStamp ReportCcy VolatilityType Volatility DeltaPercent GammaPercent RhoPercent ThetaPercent VegaPercent
0 47.0 2.298 4101.02 BlackScholes ImpliedYield Calculated Default EUR Calculated 18.338938 0.337790 0.001694 1.099958 -1.333829 4.293654
1 45.5 2.298 4101.08 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.984226 0.334519 0.001721 1.090182 -1.303460 4.277498
2 41.8 2.298 4093.84 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.668984 0.318880 0.001721 1.038611 -1.253614 4.187742
3 42.9 2.298 4097.62 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.641136 0.325123 0.001736 1.059725 -1.263150 4.225413
4 49.0 2.288 4109.39 BlackScholes ImpliedYield Calculated Default EUR Calculated 18.138334 0.350241 0.001732 1.142692 -1.340328 4.361137
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
71 67.0 2.342 4185.89 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.188230 0.482379 0.002184 1.604534 -1.242886 4.776802
72 68.2 2.342 4188.09 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.216135 0.487237 0.002180 1.621145 -1.246610 4.781439
73 66.1 2.342 4185.16 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.073377 0.480512 0.002200 1.598563 -1.233184 4.774963
74 67.7 2.342 4185.80 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.343862 0.482533 0.002162 1.604453 -1.255237 4.776777
75 67.6 2.342 4197.47 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.117453 0.507012 0.002345 1.693617 -1.163569 4.793466

76 rows × 15 columns

In [62]:
IPADf, ATIPADf = response3df.copy(), ATresponse3df.copy()  # IPA here stands for the service we used to get all the calculated valuse, Instrument Pricint Analitycs.
IPADf.index, ATIPADf.index = dfLocalTimeZone.index, ATdfLocalTimeZone.index
IPADf.columns.name = dfLocalTimeZone.columns.name
ATIPADf.columns.name = ATdfLocalTimeZone.columns.name
IPADf.rename(columns={"Volatility": 'ImpliedVolatility'}, inplace=True)
ATIPADf.rename(columns={"Volatility": 'ImpliedVolatility'}, inplace=True)
In [63]:
IPADf
Out[63]:
STXE42000B3.EX MarketValueInDealCcy RiskFreeRatePercent UnderlyingPrice PricingModelType DividendType VolatilityType UnderlyingTimeStamp ReportCcy VolatilityType ImpliedVolatility DeltaPercent GammaPercent RhoPercent ThetaPercent VegaPercent
2023-01-11 16:30:00+01:00 47.0 2.298 4101.02 BlackScholes ImpliedYield Calculated Default EUR Calculated 18.338938 0.337790 0.001694 1.099958 -1.333829 4.293654
2023-01-11 16:40:00+01:00 45.5 2.298 4101.08 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.984226 0.334519 0.001721 1.090182 -1.303460 4.277498
2023-01-11 16:50:00+01:00 41.8 2.298 4093.84 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.668984 0.318880 0.001721 1.038611 -1.253614 4.187742
2023-01-11 17:00:00+01:00 41.8 2.298 4092.42 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.777002 0.317581 0.001708 1.033869 -1.258492 4.179102
2023-01-11 17:10:00+01:00 41.8 2.298 4096.31 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.480568 0.321178 0.001743 1.046995 -1.245046 4.202855
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
2023-01-18 14:30:00+01:00 67.7 2.335 4185.80 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.346159 0.482487 0.002161 1.604296 -1.255045 4.776754
2023-01-18 14:40:00+01:00 67.7 2.335 4189.71 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.948373 0.490254 0.002218 1.632593 -1.225884 4.784252
2023-01-18 14:50:00+01:00 67.7 2.335 4191.65 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.748820 0.494263 0.002248 1.647186 -1.211058 4.787322
2023-01-18 15:00:00+01:00 67.6 2.335 4197.47 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.119869 0.506958 0.002344 1.693431 -1.163375 4.793479
2023-01-18 15:10:00+01:00 67.6 2.335 4196.28 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.245401 0.504268 0.002325 1.683656 -1.172981 4.792630

258 rows × 15 columns

In [64]:
ATIPADf
Out[64]:
MarketValueInDealCcy RiskFreeRatePercent UnderlyingPrice PricingModelType DividendType VolatilityType UnderlyingTimeStamp ReportCcy VolatilityType ImpliedVolatility DeltaPercent GammaPercent RhoPercent ThetaPercent VegaPercent
2023-01-11 16:30:00+01:00 47.0 2.298 4101.02 BlackScholes ImpliedYield Calculated Default EUR Calculated 18.338938 0.337790 0.001694 1.099958 -1.333829 4.293654
2023-01-11 16:40:00+01:00 45.5 2.298 4101.08 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.984226 0.334519 0.001721 1.090182 -1.303460 4.277498
2023-01-11 16:50:00+01:00 41.8 2.298 4093.84 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.668984 0.318880 0.001721 1.038611 -1.253614 4.187742
2023-01-11 17:20:00+01:00 42.9 2.298 4097.62 BlackScholes ImpliedYield Calculated Default EUR Calculated 17.641136 0.325123 0.001736 1.059725 -1.263150 4.225413
2023-01-12 09:00:00+01:00 49.0 2.288 4109.39 BlackScholes ImpliedYield Calculated Default EUR Calculated 18.138334 0.350241 0.001732 1.142692 -1.340328 4.361137
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
2023-01-18 13:10:00+01:00 67.0 2.342 4185.89 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.188230 0.482379 0.002184 1.604534 -1.242886 4.776802
2023-01-18 13:40:00+01:00 68.2 2.342 4188.09 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.216135 0.487237 0.002180 1.621145 -1.246610 4.781439
2023-01-18 14:00:00+01:00 66.1 2.342 4185.16 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.073377 0.480512 0.002200 1.598563 -1.233184 4.774963
2023-01-18 14:30:00+01:00 67.7 2.342 4185.80 BlackScholes ImpliedYield Calculated Default EUR Calculated 15.343862 0.482533 0.002162 1.604453 -1.255237 4.776777
2023-01-18 15:00:00+01:00 67.6 2.342 4197.47 BlackScholes ImpliedYield Calculated Default EUR Calculated 14.117453 0.507012 0.002345 1.693617 -1.163569 4.793466

76 rows × 15 columns

With out-of-trading hours

From now on we will not show AT dataframe equivalents because it is... equivalent!

In [65]:
display(searchDf2.iloc[(searchDf2['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]])

IPADfGraph = IPADf[['ImpliedVolatility', 'MarketValueInDealCcy',
                    'RiskFreeRatePercent', 'UnderlyingPrice', 'DeltaPercent',
                    'GammaPercent', 'RhoPercent', 'ThetaPercent', 'VegaPercent']]

fig = px.line(IPADfGraph)  # This is just to see the implied vol graph when that field is available
# fig.layout = dict(xaxis=dict(type="category"))

# Format Graph: https://plotly.com/python/tick-formatting/
fig.update_layout(
    title=instrument,
    template='plotly_dark')

# Make it so that only one line is shown by default: # https://stackoverflow.com/questions/73384807/plotly-express-plot-subset-of-dataframe-columns-by-default-and-the-rest-as-opt
fig.for_each_trace(
    lambda t: t.update(
        visible=True if t.name in IPADfGraph.columns[:1] else "legendonly"))

# fig.update_xaxes(autorange=True)
# fig.update_layout(yaxis=IPADf.index[0::10])

fig.show()
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
19 Eurex Dow Jones EURO STOXX 50 Index Option 420... STXE42000B3.EX 4200 EUX 2023-02-17 [.STOXX50E]

3 Graphs

In [66]:
fig = make_subplots(rows=3, cols=1)

fig.add_trace(go.Scatter(x=IPADf.index, y=IPADf.ImpliedVolatility, name='Op Imp Volatility'), row=1, col=1)
fig.add_trace(go.Scatter(x=IPADf.index, y=IPADf.MarketValueInDealCcy, name='Op Mk Pr'), row=2, col=1)
fig.add_trace(go.Scatter(x=IPADf.index, y=IPADf.UnderlyingPrice, name=underlying+' Undrlyg Pr'), row=3, col=1)


fig.update(layout_xaxis_rangeslider_visible=False)
fig.update_layout(title=IPADf.columns.name)
fig.update_layout(
    template='plotly_dark',
    autosize=False,
    width=1300,
    height=500)
fig.show()
searchDf2.iloc[(searchDf2['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]]
Out[66]:
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
19 Eurex Dow Jones EURO STOXX 50 Index Option 420... STXE42000B3.EX 4200 EUX 2023-02-17 [.STOXX50E]

Simple Graph

Certain companies are slow to update libraries, dependencies or Python versions. They/You may thus not have access to plotly (the graph library we used above). Matplotlib is rather light and should work, even on machines with old setups:

In [67]:
display(searchDf2.iloc[(searchDf2['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]])
ATIPADfSimpleGraph = ATIPADf[['ImpliedVolatility']]

fig, axes = plt.subplots(ncols=1)

ax = axes
ax.plot(ATIPADfSimpleGraph.ImpliedVolatility, '.-')
# ax.xaxis.set_major_formatter(ticker.FuncFormatter(format_date))
ax.set_title(f"{searchDf2.iloc[(searchDf2['StrikePrice']-currentUnderlyingPrc).abs().argsort()[:1]].RIC.values[0]} Implied Volatility At Trade Only")
fig.autofmt_xdate()

plt.show()
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
19 Eurex Dow Jones EURO STOXX 50 Index Option 420... STXE42000B3.EX 4200 EUX 2023-02-17 [.STOXX50E]

EUREX, or SPX Call or Put Options

Let's put it all together into a single function. This ImpVolatilityCalcIPA function will allow anyone to:

(I) find the option (i) with the index of your choice (SPX or EUREX) as underlying, (ii) closest to strike price right now (i.e.: At The Money) and (iii) with the next, closest expiry date past x days after today,

(II) calculate the Implied Volatility for that option either (i) only at times when the option itself is traded or (ii) at any time the option or the underlying is being traded.

In [68]:
def ImpVolatilityCalcIPA(x=15,
                         indexUnderlying=".STOXX50E",
                         callOrPut='Put',
                         dateBack=3,
                         expiryYearOfInterest=datetime.now().year,
                         riskFreeRate=None, riskFreeRateField=None,
                         timeZoneInGraph=datetime.now().astimezone(),
                         maxColwidth=200,
                         graphStyle='without out of trading hours',  # 'with out of trading hours', '3 graphs', 'simple'
                         simpleGraphLineStyle='.-',  # 'o-'
                         simpleGraphSize=(15, 5),
                         graphTemplate='plotly_dark',
                         debug=False,
                         returnDfGraph=False,
                         AtOptionTradeOnly=False):


    if indexUnderlying == ".STOXX50E":
        exchangeC, exchangeRIC, mcalGetCalendar = 'EUX', 'STX', 'EUREX'
    elif indexUnderlying == '.SPX':
        exchangeC, exchangeRIC, mcalGetCalendar = 'OPQ', 'SPX', 'CBOE_Futures'# 'CBOE_Index_Options'  # should be 'CBOE_Index_Options'... CBOT_Equity


    def Get_exp_dates(year=expiryYearOfInterest,
                      days=True,
                      mcal_get_calendar=mcalGetCalendar):
        '''
        Get_exp_dates Version 3.0:

        This function gets expiration dates for a year for NDX options, which are the 3rd Fridays of each month.

        Changes
        ----------------------------------------------
        Changed from Version 1.0 to 2.0: Jonathan Legrand changed Haykaz Aramyan's original code to allow
            (i) for the function's holiday argument to be changed, and defaulted to 'EUREX' as opposed to 'CBOE_Index_Options' and
            (ii) for the function to output full date objects as opposed to just days of the month if agument days=True.

        Changed from Version 2.0 to 3.0: Jonathan Legrand changed this function to reflec tthe fact that it can be used for indexes other than EUREX.

        Dependencies
        ----------------------------------------------
        Python library 'pandas_market_calendars' version 3.2

        Parameters
        -----------------------------------------------
        Input:
            year(int): year for which expiration days are requested

            mcal_get_calendar(str): String of the calendar for which holidays have to be taken into account. More on this calendar (link to Github chacked 2022-10-11): https://github.com/rsheftel/pandas_market_calendars/blob/177e7922c7df5ad249b0d066b5c9e730a3ee8596/pandas_market_calendars/exchange_calendar_cboe.py
                Default: mcal_get_calendar='EUREX'

            days(bool): If True, only days of the month is outputed, else it's dataeime objects
                Default: days=True

        Output:
            dates(dict): dictionary of expiration days for each month of a specified year in datetime.date format.
        '''

        # get CBOE market holidays
        Cal = mcal.get_calendar(mcal_get_calendar)
        holidays = Cal.holidays().holidays

        # set calendar starting from Saturday
        c = calendar.Calendar(firstweekday=calendar.SATURDAY)

        # get the 3rd Friday of each month
        exp_dates = {}
        for i in range(1, 13):
            monthcal = c.monthdatescalendar(year, i)
            date = monthcal[2][-1]
            # check if found date is an holiday and get the previous date if it is
            if date in holidays:
                date = date + timedelta(-1)
            # append the date to the dictionary
            if year in exp_dates:
                ### Changed from original code from here on by Jonathan Legrand on 2022-10-11
                if days: exp_dates[year].append(date.day)
                else: exp_dates[year].append(date)
            else:
                if days: exp_dates[year] = [date.day]
                else: exp_dates[year] = [date]
        return exp_dates

    timeOfCalcDatetime = datetime.now()  # For now, we will focuss on the use-case where we are calculating values for today; later we will allow for it historically for any day going back a few business days.
    timeOfCalcStr = datetime.now().strftime('%Y-%m-%d')
    fullDatesAtTimeOfCalc = Get_exp_dates(timeOfCalcDatetime.year, days=False)  # `timeOfCalcDatetime.year` here is 2023
    fullDatesAtTimeOfCalcDatetime = [
        datetime(i.year, i.month, i.day)
        for i in fullDatesAtTimeOfCalc[list(fullDatesAtTimeOfCalc.keys())[0]]]
    expiryDateOfInt = [i for i in fullDatesAtTimeOfCalcDatetime
                       if i > timeOfCalcDatetime + relativedelta(days=x)][0]

    if debug: print(f"expiryDateOfInt: {expiryDateOfInt}")

    response = search.Definition(
        view = search.Views.SEARCH_ALL, # To see what views are available: `help(search.Views)` & `search.metadata.Definition(view = search.Views.SEARCH_ALL).get_data().data.df.to_excel("SEARCH_ALL.xlsx")`
        query=indexUnderlying,
        select="DocumentTitle, RIC, StrikePrice, ExchangeCode, ExpiryDate, UnderlyingAsset, " +
                "UnderlyingAssetName, UnderlyingAssetRIC, ESMAUnderlyingIndexCode, RCSUnderlyingMarket" +
                "UnderlyingQuoteName, UnderlyingQuoteRIC",
        filter=f"RCSAssetCategoryLeaf eq 'Option' and RIC eq '{exchangeRIC}*' and DocumentTitle ne '*Weekly*' " +
        f"and CallPutOption eq '{callOrPut}' and ExchangeCode eq '{exchangeC}' and " +
        f"ExpiryDate ge {(expiryDateOfInt - relativedelta(days=1)).strftime('%Y-%m-%d')} " +
        f"and ExpiryDate lt {(expiryDateOfInt + relativedelta(days=1)).strftime('%Y-%m-%d')}",  # ge (greater than or equal to), gt (greater than), lt (less than) and le (less than or equal to). These can only be applied to numeric and date properties.
        top=10000,
    ).get_data()
    searchDf = response.data.df

    if debug: display(searchDf)

    try:
        underlyingPrice =  rd.get_history(
            universe=[indexUnderlying],
            fields=["TRDPRC_1"],
            interval="tick").iloc[-1][0]
    except:
        print("Function failed at the search strage, returning the following dataframe: ")
        display(searchDf)

    if debug:
        print(f"Underlying {indexUnderlying}'s price recoprded here was {underlyingPrice}")
        display(searchDf.iloc[(searchDf['StrikePrice']-underlyingPrice).abs().argsort()[:10]])

    instrument = searchDf.iloc[(searchDf['StrikePrice']-underlyingPrice).abs().argsort()[:1]].RIC.values[0]

    start = (timeOfCalcDatetime - pd.tseries.offsets.BDay(dateBack)).strftime('%Y-%m-%dT%H:%M:%S.%f')  # '2022-10-05T07:30:00.000'
    endDateTime = datetime.now()
    end = endDateTime.strftime('%Y-%m-%dT%H:%M:%S.%f')  #  e.g.: '2022-09-09T20:00:00.000'

    _optnMrktPrice = rd.get_history(
        universe=[instrument],
        fields=["TRDPRC_1"],
        interval="10min",
        start=start,  # Ought to always start at 4 am for OPRA exchanged Options, more info in the article below
        end=end)  # Ought to always end at 8 pm for OPRA exchanged Options, more info in the article below

    if debug:
        print(instrument)
        display(_optnMrktPrice)

    ## Data on certain options are stale and do not nessesarily show up on Workspace, in case that happens, we will pick the next ATM Option, which probably will have the same strike, but we will only do so once, any more and we could get too far from strike:
    if _optnMrktPrice.empty:
        if debug: print(f"No data could be found for {instrument}, so the next ATM Option was chosen")
        instrument = searchDf.iloc[(searchDf['StrikePrice']-underlyingPrice).abs().argsort()[1:2]].RIC.values[0]
        if debug: print(f"{instrument}")
        _optnMrktPrice = rd.get_history(universe=[instrument],
                                        fields=["TRDPRC_1"], interval="10min",
                                        start=start, end=end)
        if debug: display(_optnMrktPrice)
    if _optnMrktPrice.empty:  # Let's try one more time, as is often nessesary
        if debug: print(f"No data could be found for {instrument}, so the next ATM Option was chosen")
        instrument = searchDf.iloc[(searchDf['StrikePrice']-underlyingPrice).abs().argsort()[2:3]].RIC.values[0]
        if debug: print(f"{instrument}")
        _optnMrktPrice = rd.get_history(universe=[instrument],
                                        fields=["TRDPRC_1"], interval="10min",
                                        start=start, end=end)
        if debug: display(_optnMrktPrice)
    if _optnMrktPrice.empty:
        print(f"No data could be found for {instrument}, please check it on Refinitiv Workspace")

    optnMrktPrice = _optnMrktPrice.resample('10Min').mean() # get a datapoint every 10 min
    optnMrktPrice = optnMrktPrice[optnMrktPrice.index.strftime('%Y-%m-%d').isin([i for i in _optnMrktPrice.index.strftime('%Y-%m-%d').unique()])]  # Only keep trading days
    optnMrktPrice = optnMrktPrice.loc[(optnMrktPrice.index.strftime('%H:%M:%S') >= '07:30:00') & (optnMrktPrice.index.strftime('%H:%M:%S') <= '22:00:00')]  # Only keep trading hours
    optnMrktPrice.fillna(method='ffill', inplace=True)  # Forward Fill to populate NaN values

    # Note also that one may want to only look at 'At Option Trade' datapoints,
    # i.e.: Implied Volatility when a trade is made for the Option, but not when
    # none is made. For this, we will use the 'At Trade' (`AT`) dataframes:
    if AtOptionTradeOnly: AToptnMrktPrice = _optnMrktPrice

    underlying = searchDf.iloc[(searchDf['StrikePrice']).abs().argsort()[:1]].UnderlyingQuoteRIC.values[0][0]

    _underlyingMrktPrice = rd.get_history(
        universe=[underlying],
        fields=["TRDPRC_1"],
        interval="10min",
        start=start,
        end=end)
    # Let's put it al in one data-frame, `df`. Some datasets will have data
    # going from the time we sert for `start` all the way to `end`. Some won't
    # because no trade happened in the past few minutes/hours. We ought to base
    # ourselves on the dataset with values getting closer to `end` and `ffill`
    # for the other column. As a result, the following `if` loop is needed:
    if optnMrktPrice.index[-1] >= _underlyingMrktPrice.index[-1]:
        df = optnMrktPrice.copy()
        df['underlying ' + underlying + ' TRDPRC_1'] = _underlyingMrktPrice
    else:
        df = _underlyingMrktPrice.copy()
        df.rename(
            columns={"TRDPRC_1": 'underlying ' + underlying + ' TRDPRC_1'},
            inplace=True)
        df['TRDPRC_1'] = optnMrktPrice
        df.columns.name = optnMrktPrice.columns.name
    df.fillna(method='ffill', inplace=True)  # Forward Fill to populate NaN values
    df = df.dropna()

    if AtOptionTradeOnly:
        ATunderlyingMrktPrice = AToptnMrktPrice.join(
            _underlyingMrktPrice, lsuffix='_OptPr', rsuffix=' Underlying ' + underlying + ' TRDPRC_1', how='inner')
        ATdf = ATunderlyingMrktPrice

    strikePrice = searchDf.iloc[(searchDf['StrikePrice']-underlyingPrice).abs().argsort()[:1]].StrikePrice.values[0]

    if riskFreeRate is None and indexUnderlying == ".SPX":
        _riskFreeRate = 'USDCFCFCTSA3M='
        _riskFreeRateField = 'TR.FIXINGVALUE'
    elif riskFreeRate is None and indexUnderlying == ".STOXX50E":
        _riskFreeRate = 'EURIBOR3MD='
        _riskFreeRateField = 'TR.FIXINGVALUE'
    else:
        _riskFreeRate, _riskFreeRateField = riskFreeRate, riskFreeRateField

    _RfRate = rd.get_history(
        universe=[_riskFreeRate],  # USD3MFSR=, USDSOFR=
        fields=[_riskFreeRateField],
        # Since we will use `dropna()` as a way to select the rows we are after later on in the code, we need to ask for more risk-free data than needed, just in case we don't have enough:
        start=(datetime.strptime(start, '%Y-%m-%dT%H:%M:%S.%f') - timedelta(days=1)).strftime('%Y-%m-%d'),
        end=(datetime.strptime(end, '%Y-%m-%dT%H:%M:%S.%f') + timedelta(days=1)).strftime('%Y-%m-%d'))

    RfRate = _RfRate.resample('10Min').mean().fillna(method='ffill')
    df['RfRate'] = RfRate
    df = df.fillna(method='ffill')

    if AtOptionTradeOnly:
        pd.options.mode.chained_assignment = None  # default='warn'
        ATunderlyingMrktPrice['RfRate'] = [pd.NA for i in ATunderlyingMrktPrice.index]
        for i in RfRate.index:
            _i = str(i)[:10]
            for n, j in enumerate(ATunderlyingMrktPrice.index):
                if _i in str(j):
                    if len(RfRate.loc[i].values)==2:
                        ATunderlyingMrktPrice['RfRate'].iloc[n] = RfRate.loc[i].values[0][0]
                    elif len(RfRate.loc[i].values)==1:
                        ATunderlyingMrktPrice['RfRate'].iloc[n] = RfRate.loc[i].values[0]
        ATdf = ATunderlyingMrktPrice.copy()

    if timeZoneInGraph != 'GMT':
        df.index = [
            df.index[i].replace(
                tzinfo=pytz.timezone(
                    'GMT')).astimezone(
                tz=timeZoneInGraph.tzinfo)
            for i in range(len(df))]
        if AtOptionTradeOnly:
            ATdf.index = [
                ATdf.index[i].replace(
                    tzinfo=pytz.timezone(
                        'GMT')).astimezone(
                    tz=datetime.now().astimezone().tzinfo)
                for i in range(len(ATdf))]

    if AtOptionTradeOnly:
        universeL = [
            {
              "instrumentType": "Option",
              "instrumentDefinition": {
                "buySell": "Buy",
                "underlyingType": "Eti",
                "instrumentCode": instrument,
                "strike": str(strikePrice),
              },
              "pricingParameters": {
                "marketValueInDealCcy": str(ATdf['TRDPRC_1_OptPr'][i]),
                "riskFreeRatePercent": str(ATdf['RfRate'][i]),
                "underlyingPrice": str(ATdf['TRDPRC_1 Underlying ' + underlying + ' TRDPRC_1'][i]),
                "pricingModelType": "BlackScholes",
                "dividendType": "ImpliedYield",
                "volatilityType": "Implied",
                "underlyingTimeStamp": "Default",
                "reportCcy": "EUR"
              }
            }
          for i in range(len(ATdf.index))]
    else:
        universeL = [
            {
              "instrumentType": "Option",
              "instrumentDefinition": {
                "buySell": "Buy",
                "underlyingType": "Eti",
                "instrumentCode": instrument,
                "strike": str(strikePrice),
              },
              "pricingParameters": {
                "marketValueInDealCcy": str(df['TRDPRC_1'][i]),
                "riskFreeRatePercent": str(df['RfRate'][i]),
                "underlyingPrice": str(df['underlying ' + underlying + ' TRDPRC_1'][i]),
                "pricingModelType": "BlackScholes",
                "dividendType": "ImpliedYield",
                "volatilityType": "Implied",
                "underlyingTimeStamp": "Default",
                "reportCcy": "EUR"
              }
            }
          for i in range(len(df.index))]

    def Chunks(lst, n):
        """Yield successive n-sized chunks from lst."""
        for i in range(0, len(lst), n):
            yield lst[i:i + n]

    requestFields = [
        "MarketValueInDealCcy", "RiskFreeRatePercent",
        "UnderlyingPrice", "PricingModelType",
        "DividendType", "VolatilityType",
        "UnderlyingTimeStamp", "ReportCcy",
        "VolatilityType", "Volatility",
        "DeltaPercent", "GammaPercent",
        "RhoPercent", "ThetaPercent", "VegaPercent"]

    for i, j in enumerate(Chunks(universeL, 100)):
        # Example request with Body Parameter - Symbology Lookup
        request_definition = rd.delivery.endpoint_request.Definition(
            method=rd.delivery.endpoint_request.RequestMethod.POST,
            url='https://api.refinitiv.com/data/quantitative-analytics/v1/financial-contracts',
            body_parameters={
                "fields": requestFields,
                "outputs": ["Data", "Headers"],
                "universe": j})
        response2 = request_definition.get_data()
        headers_name = [h['name'] for h in response2.data.raw['headers']]
        _IPADf = pd.DataFrame(data=response2.data.raw['data'], columns=headers_name)
        if i == 0: IPADf = _IPADf
        else: IPADf = IPADf.append(_IPADf, ignore_index=True)

    if AtOptionTradeOnly:
        IPADf.index = ATdf.index
        IPADf.columns.name = ATdf.columns.name
    else:
        IPADf.index = df.index
        IPADf.columns.name = df.columns.name
    IPADf.rename(columns={"Volatility": 'ImpliedVolatility'}, inplace=True)

    # We are going to want to show details about data retreived in a dataframe in the output of this function. The one line below allows us to maximise the width (column) length of cells to see all that is written within them.
    pd.options.display.max_colwidth = maxColwidth

    if graphStyle=='simple':
        display(searchDf.iloc[(searchDf['StrikePrice']-underlyingPrice).abs().argsort()[:1]])
        IPADfSimpleGraph = IPADf[['ImpliedVolatility']]
        fig, axes = plt.subplots(ncols=1, figsize=simpleGraphSize)
        axes.plot(IPADf[['ImpliedVolatility']].ImpliedVolatility, simpleGraphLineStyle)
        if AtOptionTradeOnly: axes.set_title(f"{instrument} Implied Volatility At Trade Only")
        else: axes.set_title(f"{instrument} Implied Volatility")
        plt.show()

    else:

        display(searchDf.iloc[(searchDf['StrikePrice']-underlyingPrice).abs().argsort()[:1]])

        IPADfGraph = IPADf[['ImpliedVolatility', 'MarketValueInDealCcy',
                            'RiskFreeRatePercent', 'UnderlyingPrice', 'DeltaPercent',
                            'GammaPercent', 'RhoPercent', 'ThetaPercent', 'VegaPercent']]

        if debug: display(IPADfGraph)

        try:  # This is needed in case there is not enough data to calculate values for all timestamps , see https://stackoverflow.com/questions/67244912/wide-format-csv-with-plotly-express
            fig = px.line(IPADfGraph)
        except:
            if returnDfGraph:
                return IPADfGraph
            else:
                IPADfGraph = IPADfGraph[["ImpliedVolatility","MarketValueInDealCcy","RiskFreeRatePercent","UnderlyingPrice"]]
                fig = px.line(IPADfGraph)

        if graphStyle=='with out of trading hours':
            fig.update_layout(
                title=instrument,
                template=graphTemplate)
            fig.for_each_trace(
                lambda t: t.update(
                    visible=True if t.name in IPADfGraph.columns[:1] else "legendonly"))
            fig.show()

        elif graphStyle=='3 graphs':
            fig = make_subplots(rows=3, cols=1)

            fig.add_trace(go.Scatter(x=IPADf.index, y=IPADfGraph.ImpliedVolatility, name='Op Imp Volatility'), row=1, col=1)
            fig.add_trace(go.Scatter(x=IPADf.index, y=IPADfGraph.MarketValueInDealCcy, name='Op Mk Pr'), row=2, col=1)
            fig.add_trace(go.Scatter(x=IPADf.index, y=IPADfGraph.UnderlyingPrice, name=underlying+' Undrlyg Pr'), row=3, col=1)


            fig.update(layout_xaxis_rangeslider_visible=False)
            fig.update_layout(title=IPADfGraph.columns.name)
            fig.update_layout(
                title=instrument,
                template=graphTemplate,
                autosize=False,
                width=1300,
                height=500)
            fig.show()

        else:

            print("Looks like the agrument `graphStyle` used is incorrect. Try `simple`, `with out of trading hours` or `3 graphs`")
In [70]:
ImpVolatilityCalcIPA(  # This will pick up 10 min data
    x=15,
    indexUnderlying=".STOXX50E",  # ".SPX" or ".STOXX50E"
    callOrPut='Put',  # 'Put' or 'Call'
    dateBack=5,
    expiryYearOfInterest=datetime.now().year,
    riskFreeRate=None,
    riskFreeRateField=None,  # 'TR.FIXINGVALUE'
    timeZoneInGraph=datetime.now().astimezone(),
    maxColwidth=200,
    graphStyle='3 graphs',  # 'with out of trading hours', '3 graphs', 'simple'
    simpleGraphLineStyle='.-',  # 'o-'
    simpleGraphSize=(15, 5),
    graphTemplate='plotly_dark',
    debug=False,
    returnDfGraph=False,
    AtOptionTradeOnly=False)
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
30 Eurex Dow Jones EURO STOXX 50 Index Option 4200 Put Feb 2023 , Stock Index Cash Option, Put 4200 EUR 17-Feb-2023, Eurex STXE42000N3.EX 4200 EUX 2023-02-17 [.STOXX50E]
In [ ]:
while True:
    # Code executed here
    clear_output(wait=True)
    try:
        try:
            ImpVolatilityCalcIPA(
                dateBack=3, indexUnderlying=".STOXX50E", callOrPut='Call',
                graphStyle='simple', AtOptionTradeOnly=True)
            time.sleep(5)
        except:
            ImpVolatilityCalcIPA(
                dateBack=4,  # Sometimes, the timewindow for which the request is made is too small to produce values. This could be because of a number of reasons, and the best way round it is to simply ask for a larger time window of data.
                indexUnderlying=".STOXX50E", callOrPut='Call',
                graphStyle='simple', AtOptionTradeOnly=True)
            time.sleep(5)
    except:
        print("Please wait for next roll")
DocumentTitle RIC StrikePrice ExchangeCode ExpiryDate UnderlyingQuoteRIC
19 Eurex Dow Jones EURO STOXX 50 Index Option 4200 Call Feb 2023 , Stock Index Cash Option, Call 4200 EUR 17-Feb-2023, Eurex STXE42000B3.EX 4200 EUX 2023-02-17 [.STOXX50E]

Creating a class with PEP 3107 (a.k.a.: type hints)

We are now going to look into using PEP 3107 (and PEP 484) (and some decorators).

In [ ]:
# # import refinitiv.data as rd
# # from refinitiv.data.content import historical_pricing
# # from refinitiv.data.content.historical_pricing import Intervals
# # from refinitiv.data.content.historical_pricing import Adjustments
# # from refinitiv.data.content.historical_pricing import MarketSession
# # from refinitiv.data.content import search

# import time
# import numpy as np
# import pandas as pd
# import calendar
# import pytz
# import math
# import pandas_market_calendars as mcal  # See `https://github.com/rsheftel/pandas_market_calendars/blob/master/examples/usage.ipynb` for info on this market calendar library
# from datetime import datetime, timedelta, timezone
# from dateutil.relativedelta import relativedelta
# from pandas.tseries.offsets import BDay
# from plotly.subplots import make_subplots
# import plotly.graph_objects as go
# import plotly.express as px # This is just to see the implied vol graph when that field is available
# import matplotlib.pyplot as plt
# import matplotlib.mlab as mlab
# import matplotlib.cbook as cbook
# import matplotlib.ticker as ticker
# from IPython.display import clear_output

# try:  # The following libraries are not available in Codebook, thus this try loop
#     rd.open_session(config_name="C:\\Example.DataLibrary.Python-main\\Example.DataLibrary.Python-main\\Configuration\\refinitiv-data.config.json")
#     rd.open_session("desktop.workspace")
# except:
#     rd.open_session()
In [1]:
# print(f"Here we are using the refinitiv Data Library version {rd.__version__}")
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [1], in <cell line: 1>()
----> 1 print(f"Here we are using the refinitiv Data Library version {rd.__version__}")

NameError: name 'rd' is not defined
In [9]:
%load_ext nb_mypy
Version 1.0.4
In [2]:
%nb_mypy unknown
Unknown argument
 Valid arguments: ['', '-v', 'On', 'Off', 'DebugOn', 'DebugOff', 'mypy-options OPTIONS']
In [10]:
%nb_mypy On
In [28]:
%nb_mypy DebugOff
In [78]:
from datetime import datetime, timedelta, timezone
from datetime import date as dtdate
from dateutil.relativedelta import relativedelta  # to import types: `!pip install types-python-dateutil --trusted-host pypi.org`
import pandas_market_calendars as mcal  # See `https://github.com/rsheftel/pandas_market_calendars/blob/master/examples/usage.ipynb` for info on this market calendar library
from typing import Tuple, Union, Dict, List, Any
import numpy as np
# import nb_mypy  # !pip3 install nb_mypy --trusted-host pypi.org # https://pypi.org/project/nb-mypy/ # https://gitlab.tue.nl/jupyter-projects/nb_mypy/-/blob/master/Nb_Mypy.ipynb
import calendar
In [ ]:
class ImpliedVolatilityIPACalc():  # All about Type Hints here: https://realpython.com/python-type-checking/#static-type-checking

    def __init__(  # Constroctor
        self,
        indexUnderlying: str = ".STOXX50E"
    ):

        self.indexUnderlying: str = indexUnderlying
        self.dateBack: int = 3
        self.expiryYearOfInterest: int = datetime.now().year
        self.riskFreeRate: Union[str, None] = None
        self.riskFreeRateField: Union[str, None] = None
        self.timeZoneInGraph: datetime = datetime.now().astimezone()
        self.maxColwidth: int = 200
        self.graphStyle: str = 'without out of trading hours'  # 'with out of trading hours', '3 graphs', 'simple'
        self.simpleGraphLineStyle: str = '.-'  # 'o-'
        self.simpleGraphSize: Tuple = (15, 5)
        self.graphTemplate: str = 'plotly_dark'
        self.debug: bool = False
        self.returnDfGraph: bool = False
        self.atOptionTradeOnly: bool = True
        # def change_attrs(self, **kwargs):  for kwarg in kwargs:    self.__setattr__(kwarg, kwargs[kwarg])


    def Get_exp_dates(
        self,
        year: int = datetime.now().year,
        days: bool = True,
        mcal_get_calendar: str = 'EUREX'
    ) -> dict:
        '''
        Get_exp_dates Version 4.0:

        This function gets expiration dates for a year for NDX options, which are the 3rd Fridays of each month.

        Changes
        ----------------------------------------------
        Changed from Version 1.0 to 2.0: Jonathan Legrand changed Haykaz Aramyan's original code to allow
            (i) for the function's holiday argument to be changed, and defaulted to 'EUREX' as opposed to 'CBOE_Index_Options' and
            (ii) for the function to output full date objects as opposed to just days of the month if agument days=True.

        Changed from Version 2.0 to 3.0: Jonathan Legrand changed this function to reflec the fact that it can be used for indexes other than EUREX.

        Changed from Version 3.0 to 4.0: Jonathan Legrand changed this function to be in line with PEP 3107 (type hints).

        Dependencies
        ----------------------------------------------
        Python library 'pandas_market_calendars' version 3.2

        Parameters
        -----------------------------------------------
        Input:
            year(int): year for which expiration days are requested

            mcal_get_calendar(str): String of the calendar for which holidays have to be taken into account. More on this calendar (link to Github chacked 2022-10-11): https://github.com/rsheftel/pandas_market_calendars/blob/177e7922c7df5ad249b0d066b5c9e730a3ee8596/pandas_market_calendars/exchange_calendar_cboe.py
                Default: mcal_get_calendar='EUREX'

            days(bool): If True, only days of the month is outputed, else it's dataeime objects
                Default: days=True

        Output
        -----------------------------------------------
            dates(dict): dictionary of expiration days for each month of a specified year in datetime.date format.
        '''

        i: int  # this is for the 'for loop' in this function coming below

        # get CBOE market holidays
        Cal: mcal.get_calendar = mcal.get_calendar(mcal_get_calendar)
        holidays: Tuple[np.datetime64, ...] = Cal.holidays().holidays

        # set calendar starting from Saturday
        c: calendar.Calendar = calendar.Calendar(firstweekday=calendar.SATURDAY)

        # get the 3rd Friday of each month
        exp_dates: dict = {}  # https://stackoverflow.com/questions/48054521/indicating-multiple-value-in-a-dict-for-type-hints
        date: dtdate
        for i in range(1, 13):
            date = c.monthdatescalendar(year, i)[2][-1]
            # check if found date is an holiday and get the previous date if it is
            if date in holidays:
                date = date + timedelta(-1)
            # append the date to the dictionary
            if year in exp_dates and days:
                exp_dates[year].append(date.day)
            elif year in exp_dates:
                exp_dates[year].append(date)
            elif days:
                exp_dates[year] = [date.day]
            else:
                exp_dates[year] = [date]

        return exp_dates

    def get(
        self,
        debug: bool = False,
        after: int = 15,
        callOrPut: str = 'Put'
    ) -> ImpliedVolatilityIPACalc:

        self.after = after
        i: int  # this is for the 'for loop' in this function coming below

        self.exchangeC: str
        self.exchangeRIC: str
        self.mcalGetCalendar: str
        if self.indexUnderlying == ".STOXX50E":
            self.exchangeC, self.exchangeRIC, self.mcalGetCalendar = 'EUX', 'STX', 'EUREX'
        elif self.indexUnderlying == '.SPX':
            self.exchangeC, self.exchangeRIC, self.mcalGetCalendar = 'OPQ', 'SPX', 'CBOE_Futures'  # 'CBOE_Index_Options'  # should be 'CBOE_Index_Options'... CBOT_Equity

        timeOfCalcDatetime : datetime = datetime.now()  # For now, we will focuss on the use-case where we are calculating values for today; later we will allow for it historically for any day going back a few business days.
        timeOfCalcStr: str = datetime.now().strftime('%Y-%m-%d')
        fullDatesAtTimeOfCalc: dict = self.Get_exp_dates(
            year=timeOfCalcDatetime.year,
            days=False,
            mcal_get_calendar=self.mcalGetCalendar)
        fullDatesAtTimeOfCalcDatetime: List[datetime] = [
            datetime(i.year, i.month, i.day)
            for i in fullDatesAtTimeOfCalc[list(fullDatesAtTimeOfCalc.keys())[0]]]
        expiryDateOfInt: List[datetime] = [
            i for i in fullDatesAtTimeOfCalcDatetime
            if i > timeOfCalcDatetime + relativedelta(days=self.after)][0]

        if self.debug: print(f"expiryDateOfInt: {expiryDateOfInt}")

        response = search.Definition(
            view=search.Views.SEARCH_ALL,  # To see what views are available: `help(search.Views)` & `search.metadata.Definition(view = search.Views.SEARCH_ALL).get_data().data.df.to_excel("SEARCH_ALL.xlsx")`
            query=self.indexUnderlying,
            select="DocumentTitle, RIC, StrikePrice, ExchangeCode, ExpiryDate, UnderlyingAsset, " +
                    "UnderlyingAssetName, UnderlyingAssetRIC, ESMAUnderlyingIndexCode, RCSUnderlyingMarket" +
                    "UnderlyingQuoteName, UnderlyingQuoteRIC",
            filter=f"RCSAssetCategoryLeaf eq 'Option' and RIC eq '{self.exchangeRIC}*' and DocumentTitle ne '*Weekly*' " +
                    f"and CallPutOption eq '{callOrPut}' and ExchangeCode eq '{self.exchangeC}' and " +
                    f"ExpiryDate ge {(expiryDateOfInt - relativedelta(days=1)).strftime('%Y-%m-%d')} " +
                    f"and ExpiryDate lt {(expiryDateOfInt + relativedelta(days=1)).strftime('%Y-%m-%d')}",  # ge (greater than or equal to), gt (greater than), lt (less than) and le (less than or equal to). These can only be applied to numeric and date properties.
            top=10000,
        ).get_data()
        searchDf = response.data.df

        return self
<cell>135: error: Name "callOrPut" is not defined  [name-defined]
<cell>135: error: Name "exchangeC" is not defined  [name-defined]
In [2]:
test = ImpliedVolatilityIPACalc(indexUnderlying=".STOXX50E").get()
In [ ]:
test.exchangeC
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [ ]:
 
In [43]:
class ImpliedVolatilityIPACalc():

    def __init__(  # Constroctor
        self,
        x: int = 15,
        indexUnderlying: str = ".STOXX50E",
        callOrPut: str = 'Put',
        dateBack: int = 3,
        expiryYearOfInterest: int = datetime.now().year,
        riskFreeRate: Union[str, None] = None,
        riskFreeRateField: Union[str, None] = None,
        timeZoneInGraph: datetime = datetime.now().astimezone(),
        maxColwidth: int = 200,
        graphStyle: str = 'without out of trading hours',  # 'with out of trading hours', '3 graphs', 'simple'
        simpleGraphLineStyle: str = '.-',  # 'o-'
        simpleGraphSize: Tuple = (15, 5),
        graphTemplate: str = 'plotly_dark',
        debug: bool = False,
        returnDfGraph: bool = False,
        atOptionTradeOnly: bool = True):

        self.x = x
        self.indexUnderlying = indexUnderlying
        self.callOrPut = callOrPut
        self.dateBack = dateBack
        self.expiryYearOfInterest = expiryYearOfInterest
        self.riskFreeRate = riskFreeRate
        self.riskFreeRateField = riskFreeRateField
        self.timeZoneInGraph = timeZoneInGraph,
        self.maxColwidth = maxColwidth
        self.graphStyle = graphStyle
        self.simpleGraphLineStyle = simpleGraphLineStyle
        self.simpleGraphSize = simpleGraphSize
        self.graphTemplate = graphTemplate
        self.debug = debug
        self.returnDfGraph = returnDfGraph
        self.atOptionTradeOnly = atOptionTradeOnly


    def Get_exp_dates(
        self,
        year: int = datetime.now().year,
        days: bool = True,
        mcal_get_calendar: str = 'EUREX') -> dict:
        '''
        Get_exp_dates Version 4.0:

        This function gets expiration dates for a year for NDX options, which are the 3rd Fridays of each month.

        Changes
        ----------------------------------------------
        Changed from Version 1.0 to 2.0: Jonathan Legrand changed Haykaz Aramyan's original code to allow
            (i) for the function's holiday argument to be changed, and defaulted to 'EUREX' as opposed to 'CBOE_Index_Options' and
            (ii) for the function to output full date objects as opposed to just days of the month if agument days=True.

        Changed from Version 2.0 to 3.0: Jonathan Legrand changed this function to reflec the fact that it can be used for indexes other than EUREX.

        Changed from Version 3.0 to 4.0: Jonathan Legrand changed this function to be in line with PEP 3107 (type hints).

        Dependencies
        ----------------------------------------------
        Python library 'pandas_market_calendars' version 3.2

        Parameters
        -----------------------------------------------
        Input:
            year(int): year for which expiration days are requested

            mcal_get_calendar(str): String of the calendar for which holidays have to be taken into account. More on this calendar (link to Github chacked 2022-10-11): https://github.com/rsheftel/pandas_market_calendars/blob/177e7922c7df5ad249b0d066b5c9e730a3ee8596/pandas_market_calendars/exchange_calendar_cboe.py
                Default: mcal_get_calendar='EUREX'

            days(bool): If True, only days of the month is outputed, else it's dataeime objects
                Default: days=True

        Output
        -----------------------------------------------
            dates(dict): dictionary of expiration days for each month of a specified year in datetime.date format.
        '''

        # get CBOE market holidays
        Cal = mcal.get_calendar(mcal_get_calendar)
        holidays = Cal.holidays().holidays

        # set calendar starting from Saturday
        c = calendar.Calendar(firstweekday=calendar.SATURDAY)

        # get the 3rd Friday of each month
        exp_dates = {}
        for i in range(1, 13):
            monthcal = c.monthdatescalendar(year, i)
            date = monthcal[2][-1]
            # check if found date is an holiday and get the previous date if it is
            if date in holidays:
                date = date + timedelta(-1)
            # append the date to the dictionary
            if year in exp_dates:
                # # # Changed from original code from here on by Jonathan Legrand on 2022-10-11
                if days: exp_dates[year].append(date.day)
                else: exp_dates[year].append(date)
            else:
                if days: exp_dates[year] = [date.day]
                else: exp_dates[year] = [date]

        self.exp_dates = exp_dates

        return exp_dates

    def get(
        self,
        debug: bool = False) -> Tuple[str]:

        if self.indexUnderlying == ".STOXX50E":
            exchangeC, exchangeRIC, mcalGetCalendar = 'EUX', 'STX', 'EUREX'
        elif self.indexUnderlying == '.SPX':
            exchangeC, exchangeRIC, mcalGetCalendar = 'OPQ', 'SPX', 'CBOE_Futures'  # 'CBOE_Index_Options'  # should be 'CBOE_Index_Options'... CBOT_Equity

        timeOfCalcDatetime = datetime.now()  # For now, we will focuss on the use-case where we are calculating values for today; later we will allow for it historically for any day going back a few business days.
        timeOfCalcStr = datetime.now().strftime('%Y-%m-%d')
        fullDatesAtTimeOfCalc = self.Get_exp_dates(
            year=timeOfCalcDatetime.year,
            days=False,
            mcal_get_calendar=mcalGetCalendar)
        fullDatesAtTimeOfCalcDatetime = [
            datetime(i.year, i.month, i.day)
            for i in fullDatesAtTimeOfCalc[list(fullDatesAtTimeOfCalc.keys())[0]]]
        expiryDateOfInt = [
            i for i in fullDatesAtTimeOfCalcDatetime
            if i > timeOfCalcDatetime + relativedelta(days=self.x)][0]

        if self.debug: print(f"expiryDateOfInt: {expiryDateOfInt}")

        return exchangeC, exchangeRIC, mcalGetCalendar
In [ ]:
imp_vola_calc_ipa
In [25]:
exchangeC, exchangeRIC, mcalGetCalendar = index_underlying(underlyingName=".SPX")
In [ ]:
 
In [ ]:
def Get_exp_dates(year: int=datetime.now().year,
                  days: bool=True,
                  mcal_get_calendar: str=mcalGetCalendar
                 ) -> dict:
    '''
    Get_exp_dates Version 4.0:

    This function gets expiration dates for a year for NDX options, which are the 3rd Fridays of each month.

    Changes
    ----------------------------------------------
    Changed from Version 1.0 to 2.0: Jonathan Legrand changed Haykaz Aramyan's original code to allow
        (i) for the function's holiday argument to be changed, and defaulted to 'EUREX' as opposed to 'CBOE_Index_Options' and
        (ii) for the function to output full date objects as opposed to just days of the month if agument days=True.

    Changed from Version 2.0 to 3.0: Jonathan Legrand changed this function to reflec the fact that it can be used for indexes other than EUREX.

    Changed from Version 3.0 to 4.0: Jonathan Legrand changed this function to be in line with PEP 3107 (type hints).

    Dependencies
    ----------------------------------------------
    Python library 'pandas_market_calendars' version 3.2

    Parameters
    -----------------------------------------------
    Input:
        year(int): year for which expiration days are requested

        mcal_get_calendar(str): String of the calendar for which holidays have to be taken into account. More on this calendar (link to Github chacked 2022-10-11): https://github.com/rsheftel/pandas_market_calendars/blob/177e7922c7df5ad249b0d066b5c9e730a3ee8596/pandas_market_calendars/exchange_calendar_cboe.py
            Default: mcal_get_calendar='EUREX'

        days(bool): If True, only days of the month is outputed, else it's dataeime objects
            Default: days=True

    Output:
        dates(dict): dictionary of expiration days for each month of a specified year in datetime.date format.
    '''

    # get CBOE market holidays
    Cal = mcal.get_calendar(mcal_get_calendar)
    holidays = Cal.holidays().holidays

    # set calendar starting from Saturday
    c = calendar.Calendar(firstweekday=calendar.SATURDAY)

    # get the 3rd Friday of each month
    exp_dates = {}
    for i in range(1, 13):
        monthcal = c.monthdatescalendar(year, i)
        date = monthcal[2][-1]
        # check if found date is an holiday and get the previous date if it is
        if date in holidays:
            date = date + timedelta(-1)
        # append the date to the dictionary
        if year in exp_dates:
            ### Changed from original code from here on by Jonathan Legrand on 2022-10-11
            if days: exp_dates[year].append(date.day)
            else: exp_dates[year].append(date)
        else:
            if days: exp_dates[year] = [date.day]
            else: exp_dates[year] = [date]
    return exp_dates

Conclusion

As you can see, not only can we use IPA to gather large amounts of bespoke, calculated, values, but be can also portray this insight in a simple, quick and relevent way. The last cell in particular loops through our built fundction to give an updated graph every 5 seconds using 'legacy' technologies that would work in most environments (e.g.: Eikon Codebook).

References

Brilliant: Black-Scholes-Merton

What is the RIC syntax for options in Refinitiv Eikon?

Functions to find Option RICs traded on different exchanges

Eikon Calc Help Page

Q&A

RIC nomenclature for expired Options on Futures

Expiration Dates for Expired Options API

In [ ]: